返回文章列表

Pipeline、事务、WATCH、Lua 与 Functions

系统梳理 Redis Pipeline、MULTI/EXEC、WATCH、Lua 与 Functions 的执行语义、原子性边界、CAS、脚本运维、Cluster Slot 约束和工程选型。

第 9 章:Pipeline、事务、WATCH、Lua 与 Functions

1. 本章定位

前几章解决了“Redis 能存什么、如何操作”,本章解决两个更接近生产环境的问题:

  1. 一次业务请求需要执行很多条 Redis 命令时,怎样减少网络往返。
  2. 多条命令存在前后依赖或一致性约束时,怎样避免并发竞争。

本章涉及的机制不能混为一谈:

  • Pipeline 解决网络往返成本,不自动保证原子性。
  • MULTI/EXEC 保证一组命令连续执行,但不提供关系数据库式回滚。
  • WATCH 在事务之上提供乐观锁和 CAS(Compare-And-Set)能力。
  • Lua 把判断和更新放到 Redis 服务器内原子执行。
  • Functions 是 Redis 7.0 引入的、由服务器管理和持久化的可编程能力。

这里的“原子执行”主要指其他命令不能插入执行,不代表持久化、跨节点强一致、业务操作恰好执行一次,也不等于关系数据库完整的 ACID 事务。


2. 学习目标

完成本章后,应当能够:

  1. 准确区分 Pipeline、MULTI/EXEC、TxPipeline、WATCH、Lua 和 Functions。
  2. 解释 Pipeline 为什么提高吞吐量,以及为什么批次不能无限增大。
  3. 解释 Redis 事务中入队错误和执行期错误的不同处理方式。
  4. 使用 WATCH 实现带有限重试的乐观锁 CAS。
  5. 使用 Lua 实现库存扣减、幂等写入等原子操作。
  6. 说明长 Lua 脚本对 Redis 延迟和可用性的影响。
  7. 说明 EVAL、EVALSHA、SCRIPT LOAD 和 Functions 的版本及运维差异。
  8. 正确处理 Redis Cluster 中事务、脚本和多 Key 操作的 Slot 约束。

2.1 本章边界与跳转

本章是 Redis 原子组合能力的主章节。Go 客户端 API 的基础用法见[第 2 章](/blog/tech/Redis/02.使用 Go 操作 Redis/);Pipeline 对吞吐、内存和尾延迟的影响见[第 12 章](/blog/tech/Redis/12.Redis 高性能原理/);Cluster 同槽、MOVEDASK 和迁移语义见[第 17 章](/blog/tech/Redis/17.Redis Cluster 分片与高可用/);Lua 在分布式锁、限流、幂等和延迟任务中的工程落地见第 19 章

3. 核心概念

3.1 六种机制解决的是不同问题

机制主要目标是否减少 RTT是否防止其他命令插入是否支持条件判断关键限制
Pipeline批量发送命令客户端判断不能保证原子性
MULTI/EXEC连续执行多条命令可配合 Pipeline事务内难以根据前一条结果分支无回滚
TxPipelinePipeline 包装 MULTI/EXEC通常不做客户端读改写判断仍有执行期错误
WATCH乐观锁、CAS视调用方式而定EXEC 阶段是高冲突时重试严重
Lua服务器内判断并更新长脚本阻塞其他命令
Functions持久化、命名化的服务器函数Redis 7.0+,需部署管理

Pipeline 允许客户端连续发送多条命令而不等待每条命令的响应,从而摊薄网络 RTT 和系统调用开销;Redis 事务则通过 MULTI、EXEC 将已入队命令顺序、连续地执行。二者解决的问题不同。(Redis)


3.2 Pipeline:优化网络,不优化命令复杂度

普通串行调用大致是:

发送命令 1 -> 等待响应 1
发送命令 2 -> 等待响应 2
发送命令 3 -> 等待响应 3

Pipeline 则是:

连续发送命令 1、2、3
一次或少量几次读取响应 1、2、3

设网络 RTT 为 R,每条命令服务端执行时间为 Ci

  • 串行调用耗时近似为:N × R + ΣCi
  • Pipeline 耗时近似为:少量 RTT + ΣCi

因此,Pipeline 在网络 RTT 较高、命令本身较快时收益明显。但它不会降低 ZRANGESUNION、大 Key 删除等命令本身的 CPU 和内存成本。

Pipeline 也不是越大越好。服务器需要暂存待返回结果,客户端也需要保存命令及响应;超大批次会造成瞬时内存上涨、长时间占用连接和尾延迟恶化。官方文档建议采用合理的分批方式,而不是一次积累所有命令。(Redis)

还要注意:

  • 每条命令仍有自己的结果或错误。
  • 其他客户端的命令可能插入两个 Pipeline 命令之间执行。
  • 客户端超时时,部分或全部命令可能已经执行。
  • 对非幂等命令盲目重试,可能造成重复 INCR、重复消费或重复扣减。
  • go-redis 的 Pipeline 在超时后可能发生重发,因此写命令必须考虑重复执行风险。(GitHub)

3.3 MULTI、EXEC 与 DISCARD

Redis 事务的基本过程是:

MULTI
命令 1
命令 2
命令 3
EXEC

执行 MULTI 后,后续命令通常只返回 QUEUED,直到 EXEC 才真正执行。Redis 保证已入队命令按顺序执行,并且执行期间不会插入其他客户端的命令。

DISCARD 的作用是:

  • 清空本次事务已入队的命令。
  • 退出事务状态。
  • 不执行这些命令。

两类事务错误

错误类型发生时机示例Redis 行为
入队错误EXEC 前命令不存在、参数数量错误标记事务失败,EXEC 拒绝整个事务
执行期错误EXEC 执行命令时对非整数执行 INCR、类型不匹配当前命令报错,其他命令继续执行

例如:

SET counter abc

MULTI
INCR counter
SET marker 1
EXEC

结果可能是:

1) (error) ERR value is not an integer or out of range
2) OK

INCR 失败,但 SET marker 1 仍然执行。Redis 不会因为其中一条命令运行失败而回滚其他命令。Redis 2.6.5 以后,如果事务存在入队阶段错误,EXEC 会拒绝执行整个事务。(Redis)


3.4 Redis 事务为什么不等同于关系数据库事务

Redis 事务和关系数据库事务都有“原子执行”的表述,但含义和能力边界不同。

Redis 事务缺少的典型关系数据库能力

  1. 没有通用回滚机制

    已执行成功的 Redis 命令不会因为后续命令失败而撤销。

  2. 没有 MVCC 和事务快照

    Redis 不提供类似可重复读、读已提交、串行化等多种隔离级别。

  3. 事务内难以进行客户端分支

    命令在 EXEC 前只被排队,客户端无法获取第一条命令的执行结果,再决定是否入队第二条命令。

  4. 原子性不等于持久性

    MULTI/EXEC 是否落盘、可能丢失多少数据,仍取决于 RDB、AOF、复制和故障窗口。

  5. 不提供跨系统事务

    Redis 事务不能与 MySQL、Kafka 或外部支付系统自动形成分布式事务。

因此,更准确的描述是:

Redis 事务提供一组命令的排队和无插入执行语义,而不是关系数据库式、可回滚的完整 ACID 事务。


3.5 WATCH:基于乐观锁的 CAS

WATCH 用于解决以下经典竞争:

客户端 A:读取余额 100
客户端 B:读取余额 100
客户端 A:写入余额 80
客户端 B:写入余额 70

最终余额变成 70,客户端 A 的更新丢失。

WATCH 的流程是:

  1. WATCH key
  2. 读取 key 当前值。
  3. 在客户端计算新值。
  4. MULTI
  5. 入队更新命令。
  6. EXEC

如果从 WATCH 到 EXEC 之间,被监视的 Key 发生变化,EXEC 会放弃事务。客户端需要重新读取、计算并重试。这就是乐观锁:不提前加互斥锁,而是在提交时检测冲突。

可能触发冲突的变化不仅包括其他客户端写入,还包括过期和内存淘汰。EXEC、DISCARD 或连接结束后,WATCH 状态会被清除。(Redis)

WATCH 适合:

  • 冲突概率较低。
  • 读改写逻辑在客户端容易表达。
  • 可以接受有限次数重试。
  • 操作涉及的 Key 数量较少。

WATCH 不适合:

  • 秒杀库存等高冲突热点。
  • 客户端计算时间较长。
  • 不能接受重试放大。
  • 跨多个 Cluster Slot 操作。

Redis 8.4 为 String 增加了新的条件比较更新选项,并增加了条件删除能力。对于简单的单 Key String CAS,新命令可能比 WATCH 更直接;但它们不能替代多 Key、复杂判断场景中的 WATCH 或 Lua。(Redis)


3.6 Lua:把判断和更新放到 Redis 内部

Lua 适合将这样的逻辑合并为一次调用:

读取库存
判断库存是否足够
判断订单是否已处理
扣减库存
写入幂等记录

Redis 使用内嵌的 Lua 5.1 解释器执行脚本。脚本执行期间,其他命令不能插入到脚本内部,因此可以完成跨多个 Redis 命令的原子判断与更新。(Redis)

但需要准确理解这种原子性:

  • 它保证脚本执行过程不被其他命令穿插。
  • 它不等于关系数据库式回滚。
  • 应当先完成参数、类型和业务条件校验,再集中执行写命令。
  • 脚本应保持短小,避免循环遍历大量元素。
  • 脚本超时不代表可以任意安全中止。

如果脚本已经执行了写操作,SCRIPT KILL 通常不能将其终止,因为强行停止会破坏原子性;这种情况下极端处理可能只能关闭实例并放弃未持久化数据。(Redis)

KEYS 与 ARGV

脚本访问的所有 Key 都应通过 KEYS 显式传入:

local stockKey = KEYS[1]
local orderKey = KEYS[2]
local quantity = tonumber(ARGV[1])

不要根据数据库内容动态拼接隐藏 Key。这样做既影响 Cluster 路由,也使 Redis 无法准确分析脚本涉及的 Key。(Redis)


3.7 EVAL、EVALSHA 与 SCRIPT LOAD

EVAL

直接发送并执行脚本源码:

EVAL "return redis.call('GET', KEYS[1])" 1 user:1

优点是简单,不依赖脚本缓存;缺点是每次都携带脚本源码。

SCRIPT LOAD

将脚本加载到脚本缓存,但不执行:

SCRIPT LOAD "return redis.call('GET', KEYS[1])"

返回脚本源码的 SHA1 摘要。

EVALSHA

通过 SHA1 执行已缓存脚本:

EVALSHA <sha1> 1 user:1

脚本缓存不是业务数据的一部分,重启、故障切换或执行 SCRIPT FLUSH 后可能丢失。客户端必须处理 NOSCRIPT,重新发送源码或执行 SCRIPT LOAD。go-redis 的 redis.NewScript(...).Run(...) 会优先尝试 EVALSHA,遇到 NOSCRIPT 时回退到 EVAL。(Redis)

将 EVALSHA 放入 Pipeline 时需要特别谨慎:等客户端收到整个 Pipeline 的响应时,已经无法在原批次中间插入 SCRIPT LOAD。可选择:

  • 提前确认并加载脚本。
  • 在 Pipeline 中直接使用参数化 EVAL。
  • 使用能够正确管理脚本缓存的客户端封装。

Redis 7.4 开始,对通过 EVAL/EVAL_RO 不断产生的脚本缓存增加了数量限制和 LRU 淘汰机制,进一步说明动态拼接脚本源码是反模式。(Redis)


3.8 Functions:服务器管理的持久化函数

Redis Functions 自 Redis 7.0 引入。它与 Lua 脚本的核心执行语义相似:都在服务器中运行,都能完成原子、数据本地化的逻辑,也都必须保持短小。

主要区别如下:

对比项EVAL 脚本Functions
引入版本Redis 2.6Redis 7.0
管理者客户端应用Redis 服务器
调用方式EVAL/EVALSHAFCALL
命名通常使用 SHA1注册函数名
持久化脚本缓存易失随数据库持久化和复制
组织形式独立脚本函数库 Library
发布方式应用运行时携带或加载FUNCTION LOAD 部署
适合场景少量、轻量脚本多应用共享、需版本化管理的逻辑

Functions 通过 FUNCTION LOAD 加载函数库,通过 FCALL 调用。函数库作为数据库的一等软件制品参与持久化和复制,解决了 EVAL 脚本缓存易失、SHA1 难以运维以及多个应用实例重复管理脚本的问题。(Redis)

但 Functions 不是将大量业务代码搬入 Redis 的理由。它仍然会占用 Redis 命令执行时间,复杂循环、外部依赖和长耗时业务逻辑都不适合放入 Functions。


3.9 Cluster 中的 Slot 约束

Redis Cluster 将 Key 分配到 16384 个 Slot。事务、Lua、Functions 和其他多 Key 原子操作通常要求所有 Key 属于同一个 Slot。

可以使用 Hash Tag:

stock:{sku-42}
reservation:{sku-42}:order-1001
audit:{sku-42}

Redis 只对 {sku-42} 部分计算 Slot,因此这些 Key 会进入同一 Slot。

需要特别注意:

  • “在同一个物理节点”不等于“在同一个 Slot”。
  • Cluster 扩缩容时 Slot 可能迁移,因此必须依赖 Hash Tag,而不是当前节点分布。
  • Lua 和 Functions 访问的 Key 必须显式传入。
  • 跨 Slot 的 MULTI/EXEC、TxPipeline 或脚本通常会返回 CROSSSLOT
  • 普通 Pipeline 可以包含不同 Slot 的独立命令,Cluster 客户端可能按节点拆分发送,但这不构成跨节点事务。

Redis Cluster 采用异步复制,并明确存在已确认写入在特定故障窗口中丢失的可能。因此,同 Slot 原子执行也不能推导出跨故障的强一致或零数据丢失。(Redis)


4. 命令与 Go 使用方法

4.1 redis-cli 操作

Pipeline 没有独立命令

Pipeline 是客户端协议使用方式,而不是一条名为 PIPELINE 的 Redis 命令。

redis-cli --pipe 可以批量导入 RESP 请求:

redis-cli --pipe < batch.resp

性能测试时也可通过 -P 设置 Pipeline 深度:

redis-benchmark -t set,get -n 100000 -P 16 -q

这些操作都不提供事务原子性。

MULTI 与 EXEC

MULTI
SET account:{42}:state active
INCR account:{42}:version
EXEC

DISCARD

MULTI
SET account:{42}:state disabled
INCR account:{42}:version
DISCARD

上述两条写命令不会执行。

入队错误

MULTI
SET only-one-argument
INCR counter
EXEC

参数数量错误会导致事务在 EXEC 时整体拒绝执行。

执行期错误

SET counter abc

MULTI
INCR counter
SET marker done
EXEC

INCR 失败,但 SET marker done 仍可成功。

WATCH

客户端 A:

WATCH balance:{42}
GET balance:{42}

MULTI
SET balance:{42} 80
INCR account:{42}:debit-count
EXEC

如果客户端 B 在 A 执行 EXEC 前修改:

SET balance:{42} 90

客户端 A 的 EXEC 将放弃事务,需要重新读取并重试。

EVAL

EVAL "
local current = tonumber(redis.call('GET', KEYS[1]) or '0')
local delta = tonumber(ARGV[1])
redis.call('SET', KEYS[1], current + delta)
return current + delta
" 1 counter:{42} 5

SCRIPT LOAD 与 EVALSHA

SCRIPT LOAD "return redis.call('INCRBY', KEYS[1], ARGV[1])"
EVALSHA <返回的SHA1> 1 counter:{42} 5

Functions

函数库文件 counter.lua

#!lua name=counterlib

redis.register_function('incr_with_audit', function(keys, args)
    local delta = tonumber(args[1])
    if not delta then
        return redis.error_reply('invalid delta')
    end

    local value = redis.call('INCRBY', keys[1], delta)
    redis.call('HINCRBY', keys[2], args[2], 1)
    return value
end)

加载与调用:

redis-cli -x FUNCTION LOAD REPLACE < counter.lua
FCALL incr_with_audit 2 counter:{tenant-1} audit:{tenant-1} 5 api

4.2 Go 客户端初始化

package rediscase

import (
	"context"
	"errors"
	"fmt"
	"time"

	"github.com/redis/go-redis/v9"
)

func NewRedisClient() *redis.Client {
	return redis.NewClient(&redis.Options{
		Addr:         "127.0.0.1:6379",
		DialTimeout:  2 * time.Second,
		ReadTimeout:  1500 * time.Millisecond,
		WriteTimeout: 1500 * time.Millisecond,
		PoolTimeout:  2 * time.Second,
	})
}

redis.Client 内部管理连接池,可以被多个 goroutine 并发复用;单个 Pipeline、Tx 或事务回调应当限制在当前调用内,不要由多个 goroutine 并发写入。(GitHub)


4.3 Go 实现 Pipeline 批量查询

func BatchGetNames(
	ctx context.Context,
	rdb *redis.Client,
	ids []int64,
) (map[int64]string, error) {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	if len(ids) == 0 {
		return map[int64]string{}, nil
	}

	cmds := make([]*redis.StringCmd, len(ids))

	_, execErr := rdb.Pipelined(ctx, func(pipe redis.Pipeliner) error {
		for i, id := range ids {
			key := fmt.Sprintf("user:%d:name", id)
			cmds[i] = pipe.Get(ctx, key)
		}
		return nil
	})

	// Pipeline 返回的是首个错误。GET 未命中可能使其等于 redis.Nil,
	// 因此还需逐条检查结果。
	if execErr != nil && !errors.Is(execErr, redis.Nil) {
		return nil, fmt.Errorf("execute pipeline: %w", execErr)
	}

	result := make(map[int64]string, len(ids))
	for i, cmd := range cmds {
		name, err := cmd.Result()
		switch {
		case err == nil:
			result[ids[i]] = name
		case errors.Is(err, redis.Nil):
			// 缓存未命中,不作为基础设施异常。
			continue
		default:
			return nil, fmt.Errorf(
				"get user %d name: %w",
				ids[i],
				err,
			)
		}
	}

	return result, nil
}

关键点:

  • Pipelined 在回调结束后自动执行。
  • Pipeline 整体错误不能替代逐条检查。
  • redis.Nil 表示 Key 不存在,不等于网络或服务端故障。
  • 应限制 ids 长度,例如每批数百条,再根据 Value 大小、RTT、内存和延迟压测调整。
  • 本例只执行 GET,超时重试的副作用较小;写 Pipeline 必须重点考虑幂等性。

go-redis 还提供 Pipeline() 与手动 Exec();二者的核心区别只是控制方式,不改变 Pipeline 的原子性。(Redis)


4.4 Go 实现 TxPipeline

当多条命令必须连续执行,但不需要先读后判断时,可以使用 TxPipeline:

func RecordLogin(
	ctx context.Context,
	rdb *redis.Client,
	userID string,
	at time.Time,
) error {
	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	countKey := fmt.Sprintf("login:{%s}:count", userID)
	lastKey := fmt.Sprintf("login:{%s}:last", userID)

	_, err := rdb.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
		pipe.Incr(ctx, countKey)
		pipe.Set(ctx, lastKey, at.UnixMilli(), 0)
		return nil
	})
	if err != nil {
		return fmt.Errorf("record login transaction: %w", err)
	}

	return nil
}

TxPipelined 会使用 MULTI/EXEC 包装命令,同时采用 Pipeline 方式发送。它能够减少往返并提供事务的连续执行语义,但执行期错误仍然不会触发回滚。(Redis)


4.5 Go 使用 WATCH 实现余额 CAS

var (
	ErrAccountNotFound  = errors.New("account not found")
	ErrInsufficientFund = errors.New("insufficient fund")
)

func DebitWithWatch(
	ctx context.Context,
	rdb *redis.Client,
	accountID string,
	amount int64,
) error {
	if amount <= 0 {
		return fmt.Errorf("amount must be positive")
	}

	ctx, cancel := context.WithTimeout(ctx, 2*time.Second)
	defer cancel()

	balanceKey := fmt.Sprintf("account:{%s}:balance", accountID)
	countKey := fmt.Sprintf("account:{%s}:debit-count", accountID)

	const maxRetries = 5

	for attempt := 0; attempt < maxRetries; attempt++ {
		err := rdb.Watch(ctx, func(tx *redis.Tx) error {
			balance, err := tx.Get(ctx, balanceKey).Int64()
			switch {
			case errors.Is(err, redis.Nil):
				return ErrAccountNotFound
			case err != nil:
				return fmt.Errorf("read balance: %w", err)
			}

			if balance < amount {
				return ErrInsufficientFund
			}

			_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
				pipe.Set(ctx, balanceKey, balance-amount, 0)
				pipe.Incr(ctx, countKey)
				return nil
			})
			if err != nil {
				return fmt.Errorf("commit debit: %w", err)
			}

			return nil
		}, balanceKey)

		switch {
		case err == nil:
			return nil

		case !errors.Is(err, redis.TxFailedErr):
			// 业务错误、网络错误、解析错误不应当都按冲突重试。
			return err
		}

		// WATCH 冲突,执行有上限的退避重试。
		delay := time.Duration(attempt+1) * 10 * time.Millisecond
		select {
		case <-ctx.Done():
			return ctx.Err()
		case <-time.After(delay):
		}
	}

	return fmt.Errorf(
		"watch conflict after %d retries: %w",
		maxRetries,
		redis.TxFailedErr,
	)
}

关键点:

  • Watch 回调失败并不代表都要重试,只有 redis.TxFailedErr 表示监视冲突。
  • 重试必须有次数上限,并结合退避和抖动。
  • 两个事务 Key 使用相同 Hash Tag,保证在 Cluster 中同 Slot。
  • 本例普通 SET 会重置已有 TTL,因此业务上有 TTL 时必须显式保留或重新设置。
  • WATCH 只适合低冲突场景。热点余额或库存可能形成重试风暴。

go-redis 的 Watch 使用事务连接执行回调,WATCH 冲突时 TxPipelined 会返回 redis.TxFailedErr。(Redis)


4.6 Go 使用 Lua 实现库存原子扣减

var (
	ErrStockNotFound    = errors.New("stock not found")
	ErrInsufficientStock = errors.New("insufficient stock")
)

var reserveStockScript = redis.NewScript(`
local quantity = tonumber(ARGV[1])
local ttl = tonumber(ARGV[2])

if not quantity or quantity <= 0 or not ttl or ttl <= 0 then
    return -2
end

-- 幂等:同一个订单已经预占过,不再次扣减。
if redis.call("EXISTS", KEYS[2]) == 1 then
    return 2
end

local rawStock = redis.call("GET", KEYS[1])
if not rawStock then
    return -1
end

local stock = tonumber(rawStock)
if not stock then
    return -3
end

if stock < quantity then
    return 0
end

-- 校验完成后再集中执行写操作。
redis.call("DECRBY", KEYS[1], quantity)
redis.call("SET", KEYS[2], quantity, "EX", ttl)

return 1
`)

func ReserveStock(
	ctx context.Context,
	rdb *redis.Client,
	sku string,
	orderID string,
	quantity int64,
	ttl time.Duration,
) error {
	if quantity <= 0 {
		return fmt.Errorf("quantity must be positive")
	}
	if ttl < time.Second {
		return fmt.Errorf("ttl must be at least one second")
	}

	ctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
	defer cancel()

	stockKey := fmt.Sprintf("stock:{%s}", sku)
	reservationKey := fmt.Sprintf(
		"reservation:{%s}:%s",
		sku,
		orderID,
	)

	code, err := reserveStockScript.Run(
		ctx,
		rdb,
		[]string{stockKey, reservationKey},
		quantity,
		int64(ttl/time.Second),
	).Int64()
	if err != nil {
		return fmt.Errorf("run reserve stock script: %w", err)
	}

	switch code {
	case 1:
		return nil
	case 2:
		// 重复请求,之前已经成功预占,按幂等成功处理。
		return nil
	case 0:
		return ErrInsufficientStock
	case -1:
		return ErrStockNotFound
	case -2:
		return fmt.Errorf("invalid script argument")
	case -3:
		return fmt.Errorf("stock value is not an integer")
	default:
		return fmt.Errorf("unexpected script result: %d", code)
	}
}

该实现具有以下性质:

  • 库存判断、扣减和幂等记录在一个脚本中执行。
  • 同一订单重复请求不会重复扣减。
  • 所有 Key 通过 KEYS 传递。
  • 两个 Key 使用相同的 {sku} Hash Tag。
  • redis.NewScript 会管理 EVALSHA 与 EVAL 回退。
  • 脚本对象可作为只读的包级变量复用,不要在运行时动态拼接脚本源码。(GitHub)

Lua 能解决 Redis 内部原子性,却不能使“Redis 扣库存”和“数据库创建订单”自动成为一个跨系统事务。仍需结合状态机、补偿、Outbox 或消息幂等设计。


4.7 Go 部署和调用 Functions

const counterLibrary = `#!lua name=counterlib
redis.register_function('incr_with_audit', function(keys, args)
    local delta = tonumber(args[1])
    if not delta then
        return redis.error_reply('invalid delta')
    end

    local value = redis.call('INCRBY', keys[1], delta)
    redis.call('HINCRBY', keys[2], args[2], 1)
    return value
end)
`

func DeployCounterLibrary(
	ctx context.Context,
	rdb *redis.Client,
) error {
	ctx, cancel := context.WithTimeout(ctx, 5*time.Second)
	defer cancel()

	if err := rdb.FunctionLoadReplace(ctx, counterLibrary).Err(); err != nil {
		return fmt.Errorf("load counter function library: %w", err)
	}
	return nil
}

func IncrementWithAudit(
	ctx context.Context,
	rdb *redis.Client,
	tenantID string,
	delta int64,
	source string,
) (int64, error) {
	ctx, cancel := context.WithTimeout(ctx, 1500*time.Millisecond)
	defer cancel()

	counterKey := fmt.Sprintf("counter:{%s}", tenantID)
	auditKey := fmt.Sprintf("counter-audit:{%s}", tenantID)

	value, err := rdb.FCall(
		ctx,
		"incr_with_audit",
		[]string{counterKey, auditKey},
		delta,
		source,
	).Int64()
	if err != nil {
		return 0, fmt.Errorf("call incr_with_audit: %w", err)
	}

	return value, nil
}

函数库部署应当属于发布流程,而不是在每个业务请求中执行。在 Redis Cluster 中,函数库需要部署到所有相关主节点;Redis Cluster 不会自动把一次 FUNCTION LOAD 广播到所有主节点。(Redis)


5. 典型业务场景

场景推荐机制不适用情况数据量要求一致性要求性能风险可替代方案
批量读取用户缓存Pipeline需要整体原子结果每批数十到数百,按 Value 大小调整各 Key 独立批次过大、连接占用、响应内存MGET、数据模型聚合
同时更新登录次数和最后时间TxPipeline更新前需要读取判断少量同 Slot Key命令不可插入执行期错误不回滚Lua、单 Hash
低冲突余额 CASWATCH高热点、高冲突少量 Key冲突时放弃提交重试放大Lua、数据库事务
库存检查并扣减Lua跨系统强事务、逻辑过长少量 Key、短逻辑Redis 内原子热点、长脚本阻塞原生命令、队列串行化、数据库
多应用共享原子逻辑FunctionsRedis 6 或更早版本小型函数库Redis 内原子部署版本不一致EVAL 脚本
批量导入独立缓存Pipeline每条命令相互依赖分批写入最终一致即可超时重试导致重复写MSET、离线导入
MySQL 转账并同步 Redis不应只用 Redis 事务需要跨系统提交取决于数据库强业务一致性双写不一致数据库事务、Outbox、CDC

6. 底层实现

6.1 核心结构与算法

机制核心结构执行方式时间复杂度空间成本
Pipeline客户端命令缓冲、连接写缓冲、响应队列连续发送,批量读取O(ΣCi),另加编码和网络成本O(请求字节数 + 响应字节数)
MULTI/EXEC客户端事务状态、已入队命令序列EXEC 时逐条顺序执行O(ΣCi)O(命令数量 + 参数总大小)
WATCH被监视 Key 与客户端事务状态关联Key 改变后标记事务失效注册近似随 Key 数量增长;整体受重试次数影响与监视关系数相关
LuaLua 解释器、脚本源码及 SHA1 缓存服务器内解释执行取决于脚本及内部命令脚本缓存、Lua 临时对象和结果
Functions函数库注册表、Lua 执行环境按函数名查找并调用取决于函数逻辑函数库代码及运行时对象

这里不能只看渐近复杂度。例如一个 O(N) 脚本若扫描几十万个元素,即使只发生一次网络往返,也可能比多条 O(1) Pipeline 命令更危险。

6.2 为什么 Redis 事务不实现回滚

关系数据库回滚通常需要:

  • Undo Log。
  • 旧版本数据。
  • 锁或 MVCC。
  • 事务状态和崩溃恢复协议。

Redis 的设计重点是低延迟内存操作。它要求命令本身尽量简单、确定,并通过入队检查和应用层校验减少错误,而不是为每条 Redis 命令维护通用撤销信息。代价是事务一旦出现执行期错误,已经成功的命令不会撤销。

6.3 Pipeline 为什么能提高吞吐量

Pipeline 的收益不主要来自“Redis 一次执行多条命令”,而来自:

  1. 减少客户端等待 RTT。
  2. 减少客户端和服务器的读写系统调用次数。
  3. 让网络缓冲和协议解析更连续。
  4. 提高单连接在单位时间内传输的命令数量。

但批量请求进入服务器后,命令仍要执行。Pipeline 可能把均匀流量变成突发流量,使 CPU、复制缓冲、AOF 和网络响应出现短时尖峰。(Redis)

6.4 版本差异

版本相关变化
Redis 2.2WATCH 乐观锁机制已提供
Redis 2.6EVAL、EVALSHA、SCRIPT LOAD 和 Lua 5.1 脚本能力
Redis 7.0引入 Functions;脚本效果复制成为统一方式
Redis 7.4对不断通过 EVAL/EVAL_RO 产生的脚本缓存增加 LRU 淘汰控制
Redis 8.4String 增加更直接的条件比较更新、条件删除能力

版本升级不会改变核心原则:Pipeline 不等于事务,长脚本仍会阻塞命令执行,Cluster 多 Key 操作仍需考虑 Slot。(Redis)


7. 高性能、高并发、高可用分析

7.1 高性能

网络

Pipeline、Lua 和 Functions 都可以减少网络往返:

  • Pipeline 批量传输多个独立命令。
  • Lua 和 Functions 将多步读改写压缩为一次调用。
  • TxPipeline 同时利用批量传输和 MULTI/EXEC。

CPU

它们不会消除服务器 CPU 成本:

  • 1000 条 Pipeline 命令仍然需要执行 1000 次。
  • Lua 中的循环和大集合操作可能长时间占用命令执行线程。
  • Functions 的运行成本与函数逻辑相关。

内存

  • 大 Pipeline 会占用客户端命令缓存和服务器响应内存。
  • 事务在 EXEC 前需要保存已入队命令。
  • 动态生成大量 Lua 源码会污染脚本缓存。
  • Lua 构造大表或大结果会消耗额外内存。

磁盘与复制

Pipeline 不会绕过 AOF 和复制。批量写可能造成:

  • AOF 写入突发。
  • 主从复制流量突发。
  • 副本应用延迟升高。
  • 大量响应同时返回。

因此,批量大小必须通过业务流量和尾延迟压测确定。


7.2 高并发

WATCH 冲突

假设单次 CAS 冲突概率为 p,平均尝试次数会随 p 增大。热点库存可能出现:

并发增加
  -> WATCH 冲突增加
  -> 请求重试
  -> Redis 流量进一步增加
  -> 冲突继续增加

因此必须设置:

  • 最大重试次数。
  • 指数或线性退避。
  • 随机抖动。
  • 总超时。
  • 失败后的降级策略。

Lua 热点

Lua 避免了 WATCH 重试,但不能消除热点 Key:

  • 所有库存扣减仍集中到同一主节点。
  • 一个脚本执行时其他命令不能插入。
  • 流量突增会形成排队延迟。

重复执行

客户端超时只表示客户端没有及时收到结果,不表示 Redis 没有执行。以下操作不应无条件重试:

  • INCR
  • LPUSH
  • XADD
  • 非幂等 Lua
  • 已提交但响应丢失的 EXEC

应使用业务请求 ID、幂等 Key、结果缓存或状态机识别重复请求。


7.3 高可用

故障切换与脚本缓存

EVAL 脚本缓存可能在重启或故障切换后丢失,客户端必须处理 NOSCRIPT。Functions 随数据持久化和复制,运维语义更稳定,但 Cluster 的所有主节点仍需完成函数库部署。(Redis)

结果不确定

发生以下情况时,客户端可能无法判断命令是否已经执行:

客户端发送 EXEC/EVAL
Redis 完成执行
响应返回途中连接中断
客户端收到超时

此时直接重试可能重复执行。因此,高可用客户端不仅要“会重连”,还必须处理提交结果不确定性。

原子性不等于故障后不丢数据

MULTI/EXEC、Lua 和 Functions 的原子性发生在当前主节点内部。Redis Cluster 使用异步复制,主节点确认写入后、复制到副本前发生故障,仍可能丢失该写入。需要结合业务 RPO、持久化、复制和幂等设计判断风险。(Redis)


8. 常见错误与生产事故

8.1 把 Pipeline 当成事务

  • 现象: 订单状态已修改,但库存或计数没有同步更新。
  • 根因: 多条命令通过 Pipeline 发送,其他客户端命令在中间插入,或部分命令执行失败。
  • 排查方法: 检查代码是否使用 Pipeline/Pipelined 而不是 TxPipeline/Lua;逐条检查命令结果。
  • 修复方案: 根据业务改为 TxPipeline、WATCH 或 Lua。
  • 如何预防: 代码评审中明确标注“批量优化”和“原子操作”两类需求。

8.2 Pipeline 批次过大导致延迟抖动

  • 现象: 平均吞吐量上升,但 P99 延迟、连接池等待和 Redis 内存同时恶化。
  • 根因: 单批包含数万条命令或大量大 Value,服务器积累大量响应。
  • 排查方法: 查看批次长度、请求与响应字节数、连接池等待、内存和网络带宽。
  • 修复方案: 按命令数和字节数双重分批。
  • 如何预防: 在压测中同时观察吞吐量、P95/P99、内存和失败率,而不是只看 QPS。

8.3 误以为事务错误会回滚

  • 现象: EXEC 返回一个错误,但事务中的其他写命令已经生效。
  • 根因: 错误发生在执行期,Redis 继续执行后续命令。
  • 排查方法: 检查 EXEC 返回数组中的每一项,而不是只看是否收到响应。
  • 修复方案: 提前校验类型和参数;复杂逻辑改用经过充分校验的 Lua。
  • 如何预防: 面向错误路径编写集成测试,包括 WRONGTYPE、非法整数和 OOM 场景。

8.4 WATCH 热点形成重试风暴

  • 现象: 秒杀开始后 TxFailedErr 激增,Redis QPS 暴涨但成功率下降。
  • 根因: 大量客户端同时读取同一个版本,只有少数提交成功,其余全部重试。
  • 排查方法: 统计 WATCH 冲突率、每请求重试次数和热点 Key。
  • 修复方案: 改为 Lua 原子扣减、请求排队或分片库存。
  • 如何预防: 只在低冲突场景使用 WATCH,并设置重试上限和退避。

8.5 Lua 脚本阻塞实例

  • 现象: 整个 Redis 实例延迟突然升高,客户端出现 BUSY、超时和连接池堆积。
  • 根因: 脚本循环扫描大集合、处理大批 Key 或存在近似死循环。
  • 排查方法: 检查 SLOWLOG、LATENCY、正在执行的脚本及近期发布。
  • 修复方案: 将大任务拆分,限制循环次数;只读且尚未写入的脚本可评估 SCRIPT KILL
  • 如何预防: 为脚本设置规模上限、压测最坏数据量,并进行脚本代码审查。

8.6 故障切换后出现 NOSCRIPT

  • 现象: 主从切换后 EVALSHA 大量失败。
  • 根因: 新主节点没有相应脚本缓存。
  • 排查方法: 确认错误是否为 NOSCRIPT,检查最近的重启、切换或 SCRIPT FLUSH。
  • 修复方案: 使用 redis.NewScript.Run 或实现 EVALSHA 失败后 EVAL 回退。
  • 如何预防: 不假设脚本缓存永久存在;Pipeline 内提前加载脚本或使用 EVAL。

8.7 Cluster 返回 CROSSSLOT

  • 现象: 单机正常,迁移到 Cluster 后事务、Lua 或 Functions 调用失败。
  • 根因: 多个 Key 不属于同一个 Slot。
  • 排查方法: 使用 CLUSTER KEYSLOT 检查所有 Key 的 Slot。
  • 修复方案: 统一设计 Hash Tag,或拆分操作并接受非原子语义。
  • 如何预防: 在 Key 设计阶段确定原子操作边界,而不是上线后临时加 {}

8.8 Functions 只部署到一个 Cluster 主节点

  • 现象: 部分 Key 调用 FCALL 成功,部分返回函数不存在。
  • 根因: FUNCTION LOAD 只发送给一个主节点,Cluster 没有自动广播到其他主节点。
  • 排查方法: 逐个主节点执行 FUNCTION LIST
  • 修复方案: 在部署流程中向所有主节点加载同一版本的函数库。
  • 如何预防: 将函数库版本、校验值和节点覆盖率纳入发布检查。

9. 方案选型与权衡

方案网络优化无插入执行客户端条件判断高冲突表现Cluster 要求运维成本
单条原生命令一般单命令原子很弱通常最好单 Key 最简单
Pipeline可,但存在竞争无冲突检测可按节点拆分
MULTI/EXEC可优化不适合事务内分支不重试多 Key 同 Slot
TxPipeline不适合先读后写不重试多 Key 同 Slot
WATCH + 事务一般提交阶段是冲突时重试多 Key 同 Slot
Lua无 WATCH 重试,但会排队Key 显式且同 Slot
Functions与 Lua 类似Key 同 Slot;各主节点部署中高
关系数据库事务不属于 Redis 网络优化由数据库保证通过锁/MVCC处理不受 Redis Slot 限制中高

选型顺序可以概括为:

  1. 能用单条原生命令解决,就不要写脚本。
  2. 只是减少网络往返,使用 Pipeline。
  3. 多条固定命令需要连续执行,使用 TxPipeline。
  4. 低冲突读改写,考虑 WATCH。
  5. 高冲突、短小的 Redis 内部条件更新,考虑 Lua。
  6. Redis 7.0+、多应用共享和集中部署的脚本逻辑,考虑 Functions。
  7. 涉及跨系统强一致,回到数据库事务、状态机、消息或补偿方案。

10. 高频面试题

10.1 Pipeline 和事务有什么区别?

推荐回答:

Pipeline 优化网络往返,事务保证一组命令连续执行,两者不是同一层能力。

Pipeline 将多条命令批量发送并批量读取结果,其他客户端的命令仍可能插入。MULTI/EXEC 会先排队,再由 EXEC 顺序执行,执行期间不会插入其他命令。TxPipeline 是客户端将 Pipeline 与 MULTI/EXEC 组合起来,既减少 RTT,又获得事务执行语义。(Redis)

面试官追问:

  1. Pipeline 中一条命令失败会怎样?
  2. TxPipeline 是否支持回滚?
  3. Pipeline 批次为什么不能无限大?

常见错误回答:

“Pipeline 会把所有命令作为一个原子操作执行。”

评分点:

  • 初级: 能说出 Pipeline 减少 RTT。
  • 中级: 能区分原子性、错误处理和 TxPipeline。
  • 高级: 能讨论批次内存、超时结果不确定和重试幂等。

10.2 Redis 事务为什么不等同于关系数据库事务?

推荐回答:

Redis 事务主要保证命令顺序执行且不被插入,但没有通用回滚、MVCC、隔离级别和跨系统提交能力。

执行期错误不会撤销已经成功的命令;事务内命令在 EXEC 前只是入队,因此也不适合根据上一条结果进行客户端分支。持久性仍由 RDB、AOF 和复制策略决定。

面试官追问:

  1. Redis 事务能否称为原子操作?
  2. Redis 为什么不实现 Undo Log?
  3. 转账业务能否只依赖 MULTI/EXEC?

常见错误回答:

“Redis 事务满足完整 ACID,和 MySQL 事务相同。”

评分点:

  • 初级: 知道 Redis 事务不回滚。
  • 中级: 能解释无隔离级别和事务内分支限制。
  • 高级: 能区分原子性、持久性、业务一致性和故障一致性。

10.3 Redis 事务有哪些错误类型?

推荐回答:

分为入队错误和执行期错误。

入队错误如命令不存在、参数数量不正确,会使 EXEC 拒绝整个事务;执行期错误如 WRONGTYPE 或非法整数,只影响当前命令,后续命令仍继续执行。(Redis)

面试官追问:

  1. 客户端应如何检查 EXEC 返回值?
  2. 为什么执行期错误不回滚?
  3. DISCARD 能否撤销已经 EXEC 的事务?

常见错误回答:

“任意一条命令失败,整个事务都会自动回滚。”

评分点:

  • 初级: 知道无回滚。
  • 中级: 能准确区分两类错误。
  • 高级: 能给出预校验和故障测试方案。

10.4 WATCH 的工作原理是什么?

推荐回答:

WATCH 是乐观锁。客户端先监视 Key 并读取当前值,再通过 MULTI/EXEC 提交更新;如果监视期间 Key 被修改、过期或淘汰,EXEC 放弃提交,客户端重新读取并重试。

它本质上是 CAS,不会阻塞其他客户端。冲突率低时成本较小,热点 Key 下会因大量重试降低吞吐。(Redis)

面试官追问:

  1. WATCH 冲突后谁负责重试?
  2. WATCH 是否会锁住 Key?
  3. 为什么热点库存不适合 WATCH?

常见错误回答:

“WATCH 会给 Key 加一把分布式锁。”

评分点:

  • 初级: 知道修改后 EXEC 会失败。
  • 中级: 能说明 CAS 和有限重试。
  • 高级: 能分析冲突率、退避和热点放大。

10.5 库存扣减应该用 WATCH 还是 Lua?

推荐回答:

低冲突、逻辑简单且需要在 Go 中计算时可以使用 WATCH;高冲突库存通常更适合短 Lua,因为判断和扣减一次完成,不产生 WATCH 冲突重试。

但 Lua 仍会串行处理热点库存,不能消除单 Key 热点;流量更大时还需要排队、库存分片、限流或数据库侧最终确认。

面试官追问:

  1. Lua 如何防止重复扣减?
  2. 库存 Key 与幂等 Key 在 Cluster 中怎么设计?
  3. Redis 扣减成功而数据库下单失败怎么办?

常见错误回答:

“Lua 能保证整个下单系统的分布式事务。”

评分点:

  • 初级: 知道 Lua 可以原子扣库存。
  • 中级: 能加入幂等 Key 和 Hash Tag。
  • 高级: 能讨论跨系统补偿、状态机及热点治理。

10.6 Lua 为什么是原子的,又为什么会阻塞 Redis?

推荐回答:

Redis 执行 Lua 时,不允许其他命令插入脚本内部,因此脚本对其他客户端表现为原子操作。也正因为如此,脚本如果执行很久,其他命令只能等待,从而导致实例级延迟升高。

Lua 应限制循环和数据规模,并优先调用低复杂度 Redis 命令。已经写入数据的长脚本通常不能通过 SCRIPT KILL 强行终止。(Redis)

面试官追问:

  1. 原子执行是否等于可回滚?
  2. 如何排查长脚本?
  3. Lua 是否适合扫描大集合?

常见错误回答:

“Lua 在独立线程运行,因此不会影响其他命令。”

评分点:

  • 初级: 知道 Lua 原子。
  • 中级: 知道长脚本会阻塞。
  • 高级: 能说明终止限制、复杂度和最坏数据量测试。

10.7 EVAL、EVALSHA 和 SCRIPT LOAD 有什么区别?

推荐回答:

EVAL 携带源码并执行;SCRIPT LOAD 只加载脚本并返回 SHA1;EVALSHA 通过 SHA1 调用缓存脚本。

EVALSHA 能减少脚本源码传输,但客户端必须处理 NOSCRIPT。重启、故障切换或 SCRIPT FLUSH 后缓存可能丢失。go-redis 的 redis.NewScript.Run 会自动尝试 EVALSHA 并在缺失时回退到 EVAL。(Redis)

面试官追问:

  1. EVALSHA 放进 Pipeline 有什么问题?
  2. 能否动态生成脚本源码?
  3. SHA1 相同是否代表业务版本管理完善?

常见错误回答:

“执行过一次 EVAL 后,脚本在所有 Redis 节点永久存在。”

评分点:

  • 初级: 知道三个命令的基本功能。
  • 中级: 能处理 NOSCRIPT。
  • 高级: 能分析 Pipeline、故障切换和脚本发布策略。

10.8 Redis Functions 与 Lua 脚本是什么关系?

推荐回答:

Functions 延续了 Lua 的服务器端原子执行能力,但将代码作为 Redis 管理的命名函数库进行持久化和复制。

EVAL 脚本通常由客户端维护,缓存可能丢失;Functions 从 Redis 7.0 开始提供,通过 FUNCTION LOAD 部署、FCALL 调用,更适合多应用共享和统一版本管理。(Redis)

面试官追问:

  1. Functions 会不会阻塞 Redis?
  2. Cluster 中函数库如何部署?
  3. Redis 6 环境如何替代 Functions?

常见错误回答:

“Functions 在后台线程运行,所以可以执行长任务。”

评分点:

  • 初级: 知道 Functions 是 Redis 7.0+。
  • 中级: 能区分生命周期和调用方式。
  • 高级: 能讨论发布、回滚、集群节点覆盖和可观测性。

10.9 Cluster 中事务和 Lua 有什么限制?

推荐回答:

事务、脚本和 Functions 涉及的多个 Key通常必须属于同一个 Hash Slot。应通过 Hash Tag 让关联 Key 使用相同的 {...} 部分。

仅仅位于当前同一台主节点并不够,因为 Slot 可能迁移。普通 Pipeline 可以包含不同 Slot 的独立命令,但不能因此获得跨节点原子性。(Redis)

面试官追问:

  1. 如何检查 Key 的 Slot?
  2. 为什么同一个节点但不同 Slot 仍可能失败?
  3. 跨 Slot 业务还能怎样设计?

常见错误回答:

“只要两个 Key 当前在同一 Redis 节点,就能执行事务。”

评分点:

  • 初级: 知道 CROSSSLOT。
  • 中级: 会使用 Hash Tag。
  • 高级: 能在 Key 设计阶段确定聚合边界和分片权衡。

10.10 go-redis 中 Pipeline、Pipelined、TxPipeline、TxPipelined 有什么区别?

推荐回答:

Pipeline()TxPipeline() 返回 Pipeliner,由调用方手动执行 Exec()Pipelined()TxPipelined() 使用回调并自动执行。

前两组是否带 Tx 决定是否使用 MULTI/EXEC。redis.Client 可以并发复用,但具体 Pipeline 实例不应由多个 goroutine 并发写入。(Redis)

面试官追问:

  1. Pipeline 的 Exec 返回什么错误?
  2. 如何处理某个 GET 返回 redis.Nil?
  3. 能否在 Pipeline 回调里启动多个 goroutine?

常见错误回答:

“go-redis Client 不能并发使用,所以每个请求都要创建一个 Client。”

评分点:

  • 初级: 会调用 Pipelined。
  • 中级: 能正确逐条检查结果。
  • 高级: 能讨论连接池、批次、并发安全与超时重试。

10.11 EXEC 或 Lua 超时后能否直接重试?

推荐回答:

不能默认直接重试。客户端超时时,Redis 可能尚未执行、执行了一部分 Pipeline,或者已经完成 EXEC/Lua 但响应丢失,因此结果处于不确定状态。

非幂等操作必须引入请求 ID、幂等 Key、结果记录或状态查询。重试策略应区分连接前失败、写入后超时和明确业务失败。

面试官追问:

  1. 如何实现扣款幂等?
  2. Pipeline 超时与事务超时有何不同?
  3. Redis 能否提供 exactly-once?

常见错误回答:

“超时代表 Redis 没执行,重试一定安全。”

评分点:

  • 初级: 知道写操作重试有风险。
  • 中级: 能实现幂等 Key。
  • 高级: 能描述结果不确定、状态机和端到端语义。

10.12 Lua 或事务原子执行后,故障切换是否还会丢数据?

推荐回答:

可能。原子执行只说明当前主节点上的命令不会被其他命令插入,不代表副本已经同步,也不代表数据已经按照所需策略持久化。

Redis Cluster 使用异步复制,主节点回复后、写入到达副本前发生故障,仍存在已确认写入丢失的窗口。需要结合 AOF、复制、WAIT、业务幂等和补偿机制评估。(Redis)

面试官追问:

  1. WAIT 能否把 Redis 变成强一致系统?
  2. AOF everysec 的数据窗口是什么?
  3. 原子性、持久性和高可用分别解决什么问题?

常见错误回答:

“Lua 是原子的,所以故障切换后也绝不会丢失。”

评分点:

  • 初级: 知道原子性不等于持久性。
  • 中级: 能说明异步复制窗口。
  • 高级: 能结合 RPO、RTO、持久化和业务补偿回答。

11. 一分钟面试回答

Pipeline、事务、WATCH、Lua 和 Functions 解决的是不同问题。Pipeline 通过批量发送和读取命令减少网络 RTT,但不提供原子性,其他客户端命令可能插入,而且超时重试要考虑重复执行。MULTI/EXEC 会先排队再连续执行命令,但没有关系数据库式回滚;入队错误会导致 EXEC 拒绝事务,执行期错误只影响当前命令。TxPipeline 是 Pipeline 与 MULTI/EXEC 的组合。WATCH 是乐观锁,通过监视 Key 实现 CAS,适合低冲突场景,高冲突时会产生重试风暴。Lua 将判断和更新放到 Redis 内原子执行,适合库存、限流和幂等,但长脚本会阻塞其他命令。EVALSHA 依赖易失脚本缓存,需要处理 NOSCRIPT;Redis 7.0 的 Functions 将函数作为可持久化、可复制的软件制品管理。Cluster 中多 Key 事务、脚本和函数通常要求所有 Key 位于同一 Slot,应使用 Hash Tag。最后,Redis 内部原子性不等于跨系统事务、强一致或故障后零数据丢失。


12. 本章总结

  1. Pipeline 的核心价值是减少网络往返,不是提供事务。
  2. Redis 事务保证无插入执行,但不提供通用回滚。
  3. 入队错误会拒绝事务,执行期错误不会阻止其他命令继续执行。
  4. WATCH 是乐观锁,冲突由客户端负责有限重试。
  5. Lua 将读取、判断和写入合并到服务器内,避免客户端竞态。
  6. Lua 和 Functions 的原子性来自阻塞式执行,因此逻辑必须短小。
  7. EVALSHA 必须考虑脚本缓存丢失和 NOSCRIPT。
  8. Functions 改善了脚本的持久化、命名、发布和多应用共享能力。
  9. Cluster 中原子多 Key 操作应通过 Hash Tag 保证同 Slot。
  10. 原子执行不代表持久化、强一致、Exactly-Once 或跨系统事务。

13. 自测清单

  1. 为什么 Pipeline 可以提高吞吐量,却不能保证多条命令的原子性?
  2. Pipeline 批次过大会分别给客户端和服务器带来什么风险?
  3. MULTI 之后的命令什么时候真正执行?
  4. 入队错误与执行期错误分别如何影响 EXEC?
  5. 为什么 Redis 事务不提供关系数据库式回滚?
  6. WATCH 冲突时,go-redis 会返回什么错误,客户端应该怎样重试?
  7. 为什么高并发热点库存通常不适合 WATCH?
  8. EVALSHA 在故障切换和 Pipeline 中分别有什么风险?
  9. Functions 相比 EVAL 脚本解决了哪些生命周期问题?
  10. 如何设计库存 Key 和订单幂等 Key,使 Lua 能在 Cluster 中执行?

14. 官方资料

  • Redis 官方文档:Pipelining,说明 Pipeline 的 RTT、吞吐量与批次内存问题。(Redis)
  • Redis 官方文档:Transactions,说明 MULTI、EXEC、DISCARD、错误处理和 WATCH。(Redis)
  • Redis 官方文档:Scripting with Lua,说明 Lua 原子执行、KEYS/ARGV、脚本缓存和长脚本。(Redis)
  • Redis 官方命令文档:EVAL、EVALSHA、SCRIPT LOAD、SCRIPT KILL。(Redis)
  • Redis 官方文档:Redis Functions、FUNCTION LOAD 与 FCALL。(Redis)
  • Redis 官方文档:Redis Cluster Specification,说明 Hash Slot、多 Key 限制和异步复制的数据安全边界。(Redis)
  • go-redis 官方文档与源码:Pipeline、事务、WATCH、Script 和并发安全。(Redis)